26 Three.js 入门与核心概念认知
Three.js 入门与核心概念认知
关联:索引
要解决的问题
- 我为什么要学 Three.js:我的目标是把 Gazebo 中的仿真(世界 + 机器人 + 状态变化)在前端稳定展示,我需要掌握到什么深度?
- Gazebo → 前端展示的最短链路是什么:模型(静态几何)怎么来?位姿/关节/传感器数据怎么来?数据来了怎么驱动 3D 画面更新?
- Three.js 在这条链路里负责哪一段:它解决的是“把 3D 世界画出来”,还是也负责“把 ROS2/Gazebo 数据接进来”?边界怎么划?
- 一个 3D 画面要跑起来,最少需要哪些核心组件?它们之间如何协作才能支持“持续更新”(仿真实时变化)?
- 透视相机与正交相机有什么本质差异?在 Gazebo/工业仿真中(巡检视角、俯视态势、测量对齐)怎么选型?
- 为什么“坐标系/空间方向”是新手最大的坑:Gazebo/ROS 常见坐标系与 Three.js 右手系怎么对齐?如何用最小测试快速定位“渲染不出来/方向不对”的原因?
章节内容(本讲核心):
- 目标导向认知:Gazebo 仿真“上屏”的端到端链路分解(模型 → 场景 → 相机 → 渲染 → 数据驱动更新)
- 核心四大组件:场景 Scene、相机 Camera、渲染器 Renderer、网格 Mesh 的作用与关联(强调“持续 render + 状态更新”)
- 相机分类与选型:透视相机 PerspectiveCamera、正交相机 OrthographicCamera(巡检漫游 vs 俯视布局/对齐测量)
- 坐标系与空间认知:Three.js 右手系、相机默认朝向;Gazebo/ROS 常见坐标系到 Three.js 的对齐思路(先建立“可验证”的最小假设)
- Vue 工程化集成:在 Vue3 + TS 组件中挂载 Three.js,并完成 resize 与卸载 dispose(为路由切换/多页面展示做准备)
- 3D 基础功能测试:从“空白画布”到“能稳定看到并可持续更新”的最小闭环与排查路径
与前置知识衔接(避免重复):
-
预设学生已掌握:Vue 3 + TypeScript 基础、Vite 项目启动与模块化导入、组件生命周期(
onMounted/onBeforeUnmount)与 DOM 引用(ref)。 -
本讲新增的是:浏览器端 3D 渲染的“场景-相机-渲染”闭环逻辑,如何把 Three.js 挂载进 Vue 组件并做工程化的 resize/销毁管理,以及为后续接入 Gazebo/ROS 数据做“数据驱动更新”的接口准备。
-
原理讲解:让 AI 用“Gazebo 上屏链路类比 + 分步骤”解释四大组件的协作关系。
-
代码生成:让 AI 生成 Vue3 + TS 版本的最简 Three.js 组件骨架(含 resize 与卸载 dispose),并对照自查是否能运行。
-
场景选型:让 AI 对比透视/正交相机在 Gazebo/工业仿真场景(巡检漫游、俯视态势、测量对齐)的适用性。
-
坐标系对齐:让 AI 给出 Gazebo/ROS 坐标系到 Three.js 的映射思路,并列出“验证步骤”(先用辅助线与单一轴移动验证)。
作业:见文末(本次布置)。
- 看状态:设备/产线/仓储对象的位置、姿态、运行状态实时展示(“看见”数据)。
- 看空间:多对象空间关系、碰撞/遮挡、通行区域、危险区域。
- 看过程:工序/物流路径/AGV 轨迹/事件回放(时间轴上的空间变化)。
- 看仿真:把传感器/控制/规划的输出映射到 3D 世界形成可解释性验证。
- 3D 相比 2D 的关键价值:
- 2D 更像“列表/仪表盘”,3D 更像“空间态势图”。
- 3D 的难点不在“画”,而在“场景组织 + 相机选择 + 性能与工程化”。
1. 你要记住的最短结论(背下来就能排错)
- Scene:装东西的“世界容器”(物体、灯光、辅助线等都放进来)。
- Camera:从哪里看、怎么看(视角、距离、裁剪范围)。
- Renderer:怎么画到屏幕上(把 Scene + Camera 渲染到 Canvas)。
- Mesh:真正“可被渲染的物体”(Geometry 几何体 + Material 材质)。
把它们连成一句话:
把 Mesh 放进 Scene,用 Camera 选择视角,用 Renderer 把 Scene 渲染到页面的 Canvas。
2. 组件之间的关系图(文字版)
- 你创建并配置 Scene。
- 你创建 Camera,并把 Camera 放在某个位置(Camera 本身也是一个 3D 对象)。
- 你创建 Renderer,并把它生成的画布(
renderer.domElement)插入到 HTML 页面。 - 你创建 Mesh(几何体 + 材质),把它添加进 Scene。
- 你调用
renderer.render(scene, camera),浏览器就能看到画面。
3. Mesh 的拆分理解(避免把 Mesh 当“黑盒”)
- Geometry(几何体):形状与顶点(立方体/球体/平面/自定义模型)。
- Material(材质):怎么“上色/反光/透明”,以及是否受光照影响。
- Mesh:把 Geometry + Material 组合成可渲染对象,并拥有 transform(position/rotation/scale)。
1. Three.js 的默认坐标系与相机朝向
伸出右手:拇指、食指、中指两两垂直:拇指–>+X;食指–>+Y;中指–>+Z
- Three.js 使用右手坐标系(Right-handed)。
- 默认“上”方向:+Y 向上。
- 相机默认朝向:看向 -Z 方向(也就是你把相机放在 z 为正的位置,它会朝原点方向看过去)。
建议在任何新场景里先加 2 个辅助物体来“校准空间感”:
- AxesHelper:显示 XYZ 三轴方向。
- GridHelper:显示地面网格(方便判断尺度与方向)。
2. 物体的三大变换(最常用)
- position:位置(x, y, z)
- rotation:旋转(绕 x/y/z 轴旋转,单位是弧度)
- scale:缩放(x, y, z 的比例)
一个工业常见经验:
- 先统一“单位约定”(例如 1 单位 = 1 米),不然模型/相机参数会越来越难调。
本项目工坊目标:
- 基于你熟悉的 Vue3 + TS 工程,把 Three.js 的渲染循环“放进组件生命周期”,形成可复用的工程骨架。
- 做到四件事:能渲染、能自适应容器尺寸、能用 mock 位姿数据持续驱动更新、组件卸载后资源能正确释放(避免路由切换后越来越卡)。
1) 依赖安装(两种情况二选一)
情况 A:你已有 Vue3 + TS(Vite)项目(推荐)
# 安装 Three.js 核心库(运行时依赖)
npm i three
# 安装 Three.js 的 TypeScript 类型(仅开发期依赖;用于 vue-tsc/类型提示)
npm i -D @types/three
解释:
three:核心渲染库(包含 Scene/Camera/Renderer/Mesh 等)。@types/three:TypeScript 类型定义文件(如果缺少它,npm run build常见报错:TS7016 找不到 three 的声明文件)。
情况 B:你需要新建一个最小 Vue3 + TS 工程(可选)
# 1) 创建 Vue3 + TS 模板工程(非交互式)
npm create vite@latest threejs-vue -- --template vue-ts
# 2) 进入工程目录
cd threejs-vue
# 3) 安装模板依赖
npm i
# 4) 安装 Three.js + 类型(避免 build 阶段 TS7016)
npm i three
npm i -D @types/three
# 5) 启动开发服务器
npm run dev
解释:
npm create vite@latest ... -- --template vue-ts:创建 Vue + TS 模板工程(一次性命令,避免交互选择)。npm i:安装模板依赖。npm i three+npm i -D @types/three:安装 Three.js 与 TypeScript 类型定义文件。npm run dev:启动开发服务器,在浏览器打开命令输出的本地地址。
2) 组件落地:把四大组件写进 ThreeBasicScene.vue
建议新增一个组件文件(示例路径):
src/
├─ App.vue
└─ components/
└─ ThreeBasicScene.vue
说明:
- 我们把 Three.js 的创建与销毁写在组件里,保证你将来把它塞进任意业务页面都能跑。
- 组件容器用
div,渲染器的canvas由renderer.domElement自动生成并插入。
src/components/ThreeBasicScene.vue:
<template>
<div ref="containerRef" class="three-container"></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
const containerRef = ref<HTMLDivElement | null>(null);
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera | null = null;
let robotRoot: THREE.Group | null = null;
let robotBody: THREE.Mesh | null = null;
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let poseTimerId: number | null = null;
type Vec3 = { x: number; y: number; z: number };
type Quat = { x: number; y: number; z: number; w: number };
type Pose = {
position: Vec3;
orientation: Quat;
};
function mapPoseToThree(pose: Pose): Pose {
return pose;
}
function applyPose(object: THREE.Object3D, pose: Pose) {
const mapped = mapPoseToThree(pose);
object.position.set(mapped.position.x, mapped.position.y, mapped.position.z);
object.quaternion.set(
mapped.orientation.x,
mapped.orientation.y,
mapped.orientation.z,
mapped.orientation.w,
);
}
function createPerspectiveCamera(width: number, height: number) {
const cam = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
cam.position.set(3, 2, 5);
cam.lookAt(0, 0, 0);
return cam;
}
function createOrthographicCamera(width: number, height: number) {
const aspect = width / height;
const viewSize = 4;
const cam = new THREE.OrthographicCamera(
-viewSize * aspect,
viewSize * aspect,
viewSize,
-viewSize,
0.1,
1000,
);
cam.position.set(3, 2, 5);
cam.lookAt(0, 0, 0);
return cam;
}
function resize() {
const container = containerRef.value;
if (!container || !renderer || !camera) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height, false);
if (camera instanceof THREE.PerspectiveCamera) {
camera.aspect = width / height;
} else {
const aspect = width / height;
const viewSize = 4;
camera.left = -viewSize * aspect;
camera.right = viewSize * aspect;
camera.top = viewSize;
camera.bottom = -viewSize;
}
camera.updateProjectionMatrix();
}
function animate() {
if (!renderer || !scene || !camera) return;
renderer.render(scene, camera);
rafId = requestAnimationFrame(animate);
}
function startMockPoseStream(onPose: (pose: Pose) => void) {
const pose: Pose = {
position: { x: 0, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
};
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
const q = new THREE.Quaternion();
let t = 0;
poseTimerId = window.setInterval(() => {
t += 0.03;
pose.position.x = Math.cos(t) * 1.5;
pose.position.y = 0;
pose.position.z = Math.sin(t) * 1.5;
euler.set(0, t, 0);
q.setFromEuler(euler);
pose.orientation.x = q.x;
pose.orientation.y = q.y;
pose.orientation.z = q.z;
pose.orientation.w = q.w;
onPose(pose);
}, 50);
}
onMounted(() => {
const container = containerRef.value;
if (!container) throw new Error('Three container not found');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
const width = container.clientWidth || 1;
const height = container.clientHeight || 1;
camera = createPerspectiveCamera(width, height);
resize();
scene.add(new THREE.AxesHelper(2));
scene.add(new THREE.GridHelper(10, 10));
robotRoot = new THREE.Group();
scene.add(robotRoot);
const bodyGeometry = new THREE.BoxGeometry(0.8, 0.2, 0.6);
const bodyMaterial = new THREE.MeshBasicMaterial({ color: 0x22c55e });
robotBody = new THREE.Mesh(bodyGeometry, bodyMaterial);
robotRoot.add(robotBody);
startMockPoseStream((pose) => {
if (!robotRoot) return;
applyPose(robotRoot, pose);
});
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
animate();
});
onBeforeUnmount(() => {
if (poseTimerId !== null) window.clearInterval(poseTimerId);
if (rafId !== null) cancelAnimationFrame(rafId);
if (resizeObserver) resizeObserver.disconnect();
if (scene && robotRoot) scene.remove(robotRoot);
const geometry = robotBody?.geometry;
if (geometry && 'dispose' in geometry) geometry.dispose();
const material = robotBody?.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material?.dispose();
renderer?.dispose();
if (renderer?.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
robotBody = null;
robotRoot = null;
camera = null;
scene = null;
renderer = null;
resizeObserver = null;
poseTimerId = null;
rafId = null;
});
</script>
<style scoped>
.three-container {
width: 100%;
height: 100vh;
overflow: hidden;
}
</style>
解释(把 Vue3 + TS 的“工程脑”迁移到 Three.js):
- 组件容器:
containerRef:拿到 DOM 容器,用来挂载renderer.domElement(canvas)。height: 100vh:保证容器有高度,否则clientHeight可能为 0,导致看似“渲染失败”。- 四大组件创建:
scene = new THREE.Scene():场景容器。camera = createPerspectiveCamera(...):相机创建单独封装,便于切换透视/正交与统一 resize 逻辑。renderer = new THREE.WebGLRenderer(...):渲染器创建后把renderer.domElement插入容器。robotRoot + robotBody:把“机器人实体”作为一个 Group(根节点)管理;后续接 Gazebo 数据时,只需要更新根节点位姿即可。- 自适应(工程化关键点):
ResizeObserver监听容器尺寸变化,比window.resize更贴合业务布局(侧边栏折叠、Tabs 切换、面板拖拽)。PerspectiveCamera更新aspect;OrthographicCamera更新left/right/top/bottom。- 每次变更后必须
camera.updateProjectionMatrix()。 - 数据驱动更新(Gazebo 上屏的核心前置):
Pose用 position + quaternion 组织数据,和 ROS/Gazebo 常见的位姿表达保持一致。startMockPoseStream每 50ms 产生一次 pose:平面运动 + 绕 Y 轴旋转,模拟底盘运动与航向变化。applyPose只做一件事:把 pose 写入Object3D.position/quaternion,后续接入 Gazebo/ROS 数据时复用这一层。mapPoseToThree是“预留的坐标系对齐入口”:当你接入真实数据后出现方向不对/镜像/轴颠倒,只改这里,不要在业务各处散改。- 渲染循环:
requestAnimationFrame:每帧渲染并调用renderer.render(scene, camera),让画面持续刷新。- 销毁与释放(Vue 路由切换的必修课):
cancelAnimationFrame:停止渲染循环。clearInterval:停止 mock 数据源,避免离开页面后仍持续更新。geometry.dispose()/material.dispose()/renderer.dispose():释放 GPU 资源,避免“越切越卡”。
关键代码注释(对应上方代码,建议你逐条对照):
- 数据结构(与 Gazebo/ROS 对齐)
type Pose = { position; orientation }:位姿=位置+朝向。orientation.w:四元数的第 4 个分量;(x,y,z,w)=(0,0,0,1)表示“无旋转”,这是 ROS/Gazebo 中最常见的初始姿态。
- 坐标系对齐入口(最重要的工程策略)
mapPoseToThree(pose):现在原样返回;以后接 Gazebo/ROS 一旦出现“轴颠倒/镜像/方向不对”,只改这里。- 推荐做法:先只动一个轴(只改 x 或 y 或 z),观察 Three.js 里物体向哪边走,再决定映射规则。
- 位姿写入(统一落点)
applyPose(object, pose):把 pose 写入Object3D.position/quaternion。- 好处:数据源(mock/rosbridge/你自建网关)只负责产出 pose,渲染层只认
applyPose,接口稳定。
- 相机(Perspective vs Orthographic)
new THREE.PerspectiveCamera(60, width/height, 0.1, 1000):60:视野角(°),越大越广角、畸变更明显。width/height:宽高比,不更新会导致画面拉伸。0.1/1000:近/远裁剪面,范围外的物体会被裁掉(“明明加了模型但看不到”的高频原因)。new THREE.OrthographicCamera(left, right, top, bottom, near, far):viewSize控制可视范围(单位是“世界单位”);viewSize越大看到越多。left/right用aspect推导,避免横竖屏变化导致变形。
- resize(画面不拉伸的核心)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)):高分屏更清晰,但把 DPR 封顶到 2,防止性能/功耗暴涨。renderer.setSize(width, height, false):同步绘制缓冲区尺寸;第三个参数false表示不改 DOM style(更适合由 CSS 控制布局)。- 透视相机更新
camera.aspect;正交相机更新left/right/top/bottom;两者都必须camera.updateProjectionMatrix()才生效。
- animate(持续渲染)
requestAnimationFrame(animate):浏览器每帧回调,用于持续渲染;返回的rafId必须在卸载时cancelAnimationFrame,否则离开页面仍在跑。
- mock 数据源(验证“数据 → 画面”链路)
setInterval(..., 50):每 50ms 推一次 pose(约 20Hz),非常接近真实机器人/仿真推送频率。- 用
Euler + Quaternion生成旋转:最后仍输出四元数,保证与 ROS/Gazebo 的姿态表达一致。
- 卸载释放(真实项目必须做)
geometry.dispose()/material.dispose()/renderer.dispose():释放 GPU 资源;否则你在路由里反复进出页面会“越用越卡”。renderer.domElement从 DOM 移除:避免残留 canvas 节点。
3) 预留 rosbridge 数据接入接口(不要求当堂跑通)
你后续要接入 Gazebo/ROS 数据,建议先做一个“专用 pose topic”,在 ROS 侧把目标对象的位姿发布为 geometry_msgs/Pose(或 PoseStamped),再让前端订阅。
下面给出一个“纯 WebSocket + rosbridge 协议”的最小订阅骨架(仅展示接口与消息结构,后续 rosbridge 章节会完整联调):
type Pose = {
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
};
type RosbridgeSubscribe = {
op: 'subscribe';
topic: string;
throttle_rate?: number;
};
type RosbridgePublish<TMsg> = {
op: 'publish';
topic: string;
msg: TMsg;
};
type RosPoseMsg = {
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
};
function createRosbridgePoseStream(
options: { url: string; topic: string },
onPose: (pose: Pose) => void,
) {
const ws = new WebSocket(options.url);
ws.addEventListener('open', () => {
const sub: RosbridgeSubscribe = { op: 'subscribe', topic: options.topic, throttle_rate: 20 };
ws.send(JSON.stringify(sub));
});
ws.addEventListener('message', (ev) => {
try {
const data = JSON.parse(String(ev.data)) as RosbridgePublish<RosPoseMsg>;
if (data.op !== 'publish') return;
if (data.topic !== options.topic) return;
const pose: Pose = { position: data.msg.position, orientation: data.msg.orientation };
onPose(pose);
} catch {
return;
}
});
return () => {
ws.close();
};
}
解释:
- 这段代码假设:rosbridge 服务端已启动(默认
ws://localhost:9090),并且 ROS 侧存在一个能直接输出position + orientation的 topic(例如/web_pose)。 - 渲染层的统一落点仍然是
applyPose(robotRoot, pose):无论数据来自 mock、rosbridge、还是你自定义 WebSocket 服务端,都让数据源只负责“产出 pose”,在 onPose 回调里统一写入 3D 对象。 RosbridgeSubscribe:前端发给 rosbridge 的订阅指令,其中throttle_rate单位是毫秒(ms),用于“限频”,避免推太快导致前端掉帧。RosbridgePublish:rosbridge 推给前端的消息格式;需要检查op==='publish'且topic===目标 topic,避免把其他 topic 的数据误当 pose。try/catch(JSON.parse):必须做防御;rosbridge 或你的网络链路里可能混入非 JSON 文本/非预期结构,如果不捕获会导致回调抛异常、后续消息处理被中断。return () => ws.close():把“断开连接”封装成函数,方便在组件卸载/切换数据源时稳定释放资源。
4) 数据源适配器(PoseSource):mock / rosbridge 一键切换(推荐升级)
如果你后续要把同一套前端界面同时用于:
- 本地演示(没有 ROS,只有 mock)
- 真实联调(ROS + rosbridge 推位姿)
建议把“位姿数据源”抽象成统一接口 PoseSource:Three.js 渲染层只关心 Pose,不关心数据来自哪里。
下面是一份可直接粘贴的最小适配器骨架(你可以把它放在 ThreeBasicScene.vue 内部,替换掉 startMockPoseStream 相关逻辑):
使用前置:
- 复用你在组件里已经定义好的
Pose类型(position + orientation),不要在同一作用域重复定义type Pose = ...。 - 下面代码默认你在同一个组件里已经
import * as THREE from 'three'。
type PoseSource = {
start: (onPose: (pose: Pose) => void) => void;
stop: () => void;
};
function createMockPoseSource(): PoseSource {
let timerId: number | null = null;
return {
start(onPose) {
const pose: Pose = {
position: { x: 0, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
};
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
const q = new THREE.Quaternion();
let t = 0;
timerId = window.setInterval(() => {
t += 0.03;
pose.position.x = Math.cos(t) * 1.5;
pose.position.y = 0;
pose.position.z = Math.sin(t) * 1.5;
euler.set(0, t, 0);
q.setFromEuler(euler);
pose.orientation.x = q.x;
pose.orientation.y = q.y;
pose.orientation.z = q.z;
pose.orientation.w = q.w;
onPose(pose);
}, 50);
},
stop() {
if (timerId !== null) window.clearInterval(timerId);
timerId = null;
},
};
}
type RosbridgeSubscribe = { op: 'subscribe'; topic: string; throttle_rate?: number };
type RosbridgeUnsubscribe = { op: 'unsubscribe'; topic: string };
type RosbridgePublish<TMsg> = { op: 'publish'; topic: string; msg: TMsg };
type RosPoseMsg = Pose;
function createRosbridgePoseSource(options: { url: string; topic: string }): PoseSource {
let ws: WebSocket | null = null;
return {
start(onPose) {
ws = new WebSocket(options.url);
ws.addEventListener('open', () => {
const sub: RosbridgeSubscribe = { op: 'subscribe', topic: options.topic, throttle_rate: 50 };
ws?.send(JSON.stringify(sub));
});
ws.addEventListener('message', (ev) => {
try {
const data = JSON.parse(String(ev.data)) as RosbridgePublish<RosPoseMsg>;
if (data.op !== 'publish') return;
if (data.topic !== options.topic) return;
onPose({ position: data.msg.position, orientation: data.msg.orientation });
} catch {
return;
}
});
},
stop() {
if (!ws) return;
if (ws.readyState === WebSocket.OPEN) {
const unsub: RosbridgeUnsubscribe = { op: 'unsubscribe', topic: options.topic };
ws.send(JSON.stringify(unsub));
}
ws.close();
ws = null;
},
};
}
function selectPoseSource(): PoseSource {
const mode = import.meta.env.VITE_POSE_SOURCE as string | undefined;
if (mode === 'rosbridge') {
const url = (import.meta.env.VITE_ROSBRIDGE_URL as string | undefined) ?? 'ws://localhost:9090';
const topic = (import.meta.env.VITE_POSE_TOPIC as string | undefined) ?? '/web_pose';
return createRosbridgePoseSource({ url, topic });
}
return createMockPoseSource();
}
解释:
PoseSource只暴露start/stop,渲染层只需要“开始接收 pose”与“停止接收 pose”。selectPoseSource()用 Vite 的环境变量做开关:VITE_POSE_SOURCE=rosbridge:走 rosbridge 数据源- 未设置或其他值:默认走 mock
- rosbridge 的
throttle_rate单位是毫秒(ms),示例取 50ms 约等于 20Hz 推送频率,你可以按渲染负载与网络情况调大或调小。 - 这个结构的价值是:你后续接入 Gazebo 时,Three.js 侧的唯一落点仍然是
applyPose(robotRoot, pose),不会因为数据源变化导致代码散落。 start(onPose)的约定:数据源内部负责建立连接/定时器,并在收到新数据时回调onPose(pose);渲染层不要在数据源内部夹杂 Three.js 逻辑。stop()的约定:必须能“彻底停掉”数据源(清理定时器、关闭 WebSocket、取消订阅)。这决定了你路由切换/热更新时是否会出现“越来越多连接/越来越卡”的隐性 bug。unsubscribe的意义:即使前端直接ws.close(),服务端也可能在某些实现里继续保留订阅状态;显式发unsubscribe能减少服务端推送负担(更符合工程习惯)。- Vite 环境变量提示:只有以
VITE_开头的变量才会注入到import.meta.env;你可以把它们写到项目根目录的.env.local(不提交)或在命令行临时设置。
把它接入到组件的最小方式:
// 1) 选择数据源(mock / rosbridge)
const poseSource = selectPoseSource();
// 2) 启动数据源:每次收到 pose,都统一写入 3D 对象
poseSource.start((pose) => {
if (!robotRoot) return;
applyPose(robotRoot, pose);
});
// 3) 组件卸载时停掉数据源(避免 WebSocket/定时器后台继续跑)
onBeforeUnmount(() => {
poseSource.stop();
});
解释:
poseSource.start(...)的回调只负责把 pose 写入 Three.js 对象。onBeforeUnmount一定要stop(),否则 WebSocket/定时器会在后台继续跑,表现为“离开页面后越来越卡/越来越多连接”。- 如果你的 pose topic 推送频率很高(例如 60Hz+),建议先把
throttle_rate调大(例如 50~100ms),优先保证“稳定渲染 + 不掉帧”,再逐步优化。
5) 在页面中使用组件
src/App.vue(最小接入):
<template>
<ThreeBasicScene />
</template>
<script setup lang="ts">
import ThreeBasicScene from './components/ThreeBasicScene.vue';
</script>
解释:
App.vue只负责把组件渲染出来,Three.js 逻辑全部在ThreeBasicScene.vue内部可控。<script setup>:Vue3 的组合式语法糖,默认就是组件作用域;这里把场景组件当普通业务组件使用即可。
你已经在 ThreeBasicScene.vue 里完成了“Gazebo 上屏骨架”。本段只做两件事:确认相机切换没问题、确认坐标系对齐验证路径清晰(为后续接真实 Gazebo 数据做准备)。
- 页面渲染后能看到:背景色 + 坐标轴/网格 + 机器人实体(绿色方块)。
- mock 位姿更新生效:机器人实体在平面上运动并旋转(说明“数据 → 画面更新”链路已跑通)。
- 切换透视/正交相机并截图对比:确认你能解释“为什么俯视/对齐更适合正交”。
- 坐标系对齐验证口径(只说步骤,不做复杂推导):
- 先只动 X,再只动 Y,再只动 Z(每次只改一个轴,观察物体向哪个方向移动)。
- 再只绕某一轴旋转(只改一个旋转分量,观察绕哪个轴转)。
- 发现方向不对时,只改
mapPoseToThree,不要在业务各处散改。
- 路由切换离开页面后:不继续更新、不出现控制台报错(资源已释放)。
1) 本质差异一句话
- 透视相机:近大远小,更符合人眼,适合漫游/沉浸式观察。
- 正交相机:没有透视缩放,尺寸更“像工程图”,适合测量/俯视/设备布局。
| 场景 | 更推荐相机 | 原因 |
|---|---|---|
| 园区/车间数字孪生漫游、设备巡检视角 | 透视相机 | 更真实、更易理解空间层次 |
| 俯视态势图、产线布局、平面规划 | 正交相机 | 不失真,便于对齐与测量 |
| 需要同时支持“宏观漫游 + 局部精准对齐” | 双相机切换 | 工程上常见:漫游用透视,定位/对齐用正交 |
3) 正交相机最小替换(只改 Camera 部分)
在 Vue 组件中,你只需要把相机创建从 createPerspectiveCamera(...) 切换为 createOrthographicCamera(...)(保留同样的 resize() 逻辑即可)。
建议你做一个最小实验:
- 用透视相机截图一次(能明显看出近大远小)。
- 切换到正交相机再截图一次(物体大小不会随距离变化)。
如果你想保留“可切换”的工程结构(更贴近工业项目),建议把相机创建抽成 createPerspectiveCamera/createOrthographicCamera 两个函数(示例代码已给出),再通过一个变量或 UI 开关控制使用哪个相机。
当你遇到“黑屏/空白/看不到物体”,按下面顺序自检(强制执行,避免乱猜):
-
Vue/DOM 层面:容器是否有尺寸(宽高是否为 0)?控制台是否有报错?组件是否重复 mount?
-
渲染器层面:
renderer.domElement是否插入容器?setSize是否与容器一致?devicePixelRatio是否过高导致性能异常? -
相机层面:相机位置是否合理?是否
lookAt(0,0,0)?near/far是否覆盖目标距离? -
场景层面:是否至少添加了
AxesHelper/GridHelper作为参照物? -
物体层面:物体是否加入
scene.add(mesh)?位置是否在视野内? -
材质与光照:入门阶段优先用
MeshBasicMaterial验证“能看见”,再引入受光照影响的材质与灯光。 -
工程化层面:离开页面后是否停止渲染循环并
dispose?否则“没有报错但越来越卡”会非常隐蔽。 -
写下你的“Gazebo 上屏目标一句话”(示例:在网页里显示机器人底盘模型,并实时更新它的位姿)。
-
在 Vue3 + TS 工程中安装
three,创建ThreeBasicScene.vue并渲染成功。 -
在组件内创建并关联四大组件:Scene、Camera、Renderer、Mesh,并添加
AxesHelper/GridHelper(用于坐标系校验)。 -
做一次“仿真数据驱动更新”的最小实验(不接 ROS,先用 mock):每 50ms 更新一次物体的 position/rotation,画面稳定刷新。
-
完成一个工程化自检点:路由切换离开该页面后不再渲染、无报错(体现
onBeforeUnmount的资源释放)。
- 打开页面无控制台报错。
- 能截图证明:画面中有立方体 + 坐标轴/网格(至少其一)。
- 能口述四大组件协作链路(不看也能说清)。
- 能解释“为什么 Three.js 在 Vue 中必须做销毁与 dispose”(说清资源泄漏会导致的问题)。
- 能解释“为什么必须先做坐标轴/网格校验再接 Gazebo 数据”(说清对齐验证的意义)。
大模型任务(可直接复制使用)
任务 1:讲解四大组件的协作逻辑
请用“Gazebo 仿真上屏链路类比 + 分步骤”的方式讲解 Three.js 的四大核心组件 Scene、Camera、Renderer、Mesh:
1) 每个组件各自负责什么;
2) 它们之间的调用顺序与依赖关系(从创建到持续 render);
3) 当我把 Gazebo/ROS 的位姿数据接入后,应该把“数据更新”放在哪一层(更新 mesh 还是重建 scene);
4) 新手最常见的 5 个黑屏/方向不对原因,并给出排查顺序。
要求:输出结构化要点,适合入门学习者,尽量用仿真上屏的例子类比。
任务 2:生成最简 Three.js 基础场景代码(含注释)
请生成一个“Gazebo 上屏骨架(先 mock 数据)”的最简可运行代码,要求:
- 使用 Vue3 + Vite + TypeScript;
- 输出两个文件的完整代码:
1) src/components/ThreeBasicScene.vue
2) src/App.vue(负责引入并渲染组件)
- 场景包含:Scene、PerspectiveCamera、WebGLRenderer、一个立方体 Mesh;
- 加上 AxesHelper 或 GridHelper;
- 需要有容器 resize 自适应;
- 需要有 onBeforeUnmount 的资源释放(cancelAnimationFrame + dispose)。
- 增加一个 mock “位姿更新”定时器:周期性更新 mesh.position 与 mesh.rotation,模拟 Gazebo 推送的数据。
输出格式:先给目录结构,再分别给出两个文件的完整代码。
任务 3:对比两种相机的工业适用场景
请对比 PerspectiveCamera 与 OrthographicCamera(面向 Gazebo/工业仿真展示):
1) 本质差异(从成像与用途角度);
2) 关键参数分别是什么(fov/aspect/near/far 与 left/right/top/bottom/near/far);
3) 给出至少 4 个工业 3D 场景,并说明更适合哪种相机以及原因;
4) 推荐一个“项目中的相机选型策略”(例如双相机切换),并给出注意事项。
要求:用表格输出对比,并给出简短结论。
任务 4:坐标系对齐与验证步骤(强烈建议做)
我准备用 Three.js 在网页展示 Gazebo/ROS 的机器人位姿数据。请给出一个“坐标系对齐与验证”的最小方案:
1) Three.js 右手系、相机默认朝向的关键结论;
2) 如果我拿到的是 ROS 中的位姿(位置 + 四元数),我应该先做哪些最小假设(例如单位、轴向、旋转顺序);
3) 给出一个验证步骤清单:先只动 X,再只动 Y,再只动 Z;再只绕某一轴旋转;每步应该看到什么;
4) 列出 5 个最常见的对齐错误与表现(镜像/轴颠倒/单位不一致/角度制 vs 弧度/四元数顺序错误等)。
要求:用 checklist 输出,并能直接用于课堂验收。
作业(本次布置)
- 完成 Three.js 基础环境搭建(Vue3 + TS 工程),创建一个包含场景、相机、渲染器的空白 3D 场景,确保页面能正常渲染(无报错)。
提交要求:
- 提交
ThreeBasicScene.vue(或等价实现),以及一张浏览器截图(能看见背景色或辅助线即可)。
- 用 mock 数据做一次“仿真上屏最小演示”:让一个立方体在画面中沿 X 轴匀速移动,并绕 Y 轴旋转(模拟底盘运动与航向变化),截图并标注你更新的字段(position/rotation)。
提交要求:
- 截图 1 张,并用文字标注:position 的哪个分量在变化、rotation 绕哪个轴在变化、更新周期是多少。
- 分别配置透视相机与正交相机,截图两种相机的渲染效果,标注关键参数(fov、aspect、near、far 或正交视域 viewSize)。
提交要求:
- 透视相机截图 1 张、正交相机截图 1 张;
- 截图旁标注关键参数值,并写一句话说明你为什么选它(巡检漫游/俯视对齐/测量等)。
- 撰写 200 字左右说明:简述四大核心组件的作用,并用 3 句话说明“Gazebo 数据到 Three.js 画面更新”的链路(不要求写代码,要求边界清晰)。
评分要点(参考):
- 组件作用是否清晰且不混淆(Scene/Camera/Renderer/Mesh)。
- 能否说明“为什么某些工业场景更适合正交/透视”。
- 是否给出一个可执行的选型原则(例如:漫游用透视、对齐测量用正交)。